vlwkaos' digital garden

JavaScript - Event Loop

Event Loop

document.body.appendChild(el)
elem.style.display = "none"

어떤 순서로 실행될까? Element가 붙고나서 잠깐이라도 보여지지 않을까? Event Loop을 잘 이해한다면 그런 걱정은 하지 않아도 된다.

Main Thread에서 모든 것은 결정적으로 처리된다.

while (true); // Main thread blocking, render blocking
function loop() {
  setTimeout(loop, 0) // queued for next loop, not render blocking
}
loop()

Rendering step

ℹ️ Event loop의 rendering 과정에서는 대략 Style, Layout, Paint순으로 렌더링을 시도한다.

브라우저가 항상 render 하는 것은 아니다. 하지만 render시점에 callback 함수를 호출하고 싶으면 다음을 이용할 수 있다.

requestAnimationFrame() // rAF

rAFrendering바로 직전에 실행되는 것이 브라우저 표준 구현방식이다.

Event loop의 매 시작에는 render step이 있다. Render step이 끝난 후 메인 쓰레드의 남는 자원은 task queue의 event처리에 쓰인다.

그 말인 즉슨, task event의 경우 event loop의 어느 시점에 처리될 지 정확히 알 수 없다는 말이 된다. 그렇기 때문에 애니메이션등의 그려지는 동작의 통일성을 보장하려면 setTimeout을 쓰면안된다.

setTimout은 단순히 task를 대기열에 넣고 event loop이 여유가 있을 때마다 실행한다. 렌더링 과정이 한번 일어나기 전에 여러번 호출 될 수 있다는 얘기이다. 위치 값 갱신 같은 작업이 그려지기 전에 두번 이상 발생하면 정확한 애니메이션을 그릴 수 없다. 또한 만약 작업이 오래 걸리는 경우 병목이 생기면서 다음 event loop를 지연시킬 수도 있기 때문에 버벅임이 발생할 가능성도 있다.

같은 이유로 render관련된 서버 동작이 있을 경우에 setTimeout대신 rAF을 이용하면 불필요한 호출을 줄일 수 있을 것이다.

다음 예제를 보자:

클릭시에 상자를 1000px의 위치로 이동시켰다가 500px으로 이동시키는 코드이다.

button.addEventListener('click', () => {
    box.style.transform = 'translateX(1000px)';
    box.style.transition = 'transform 1s ease-out';
    box.style.transform = 'translateX(500px)';
});

하지만 이 상태로 실행하면 상자는 0의 위치에서부터 500으로 이동한다. 왜 그럴까?

아까 말했듯 rendering step이 발생하지 않은 상태에서 실행이 됐기 때문에 마지막 값으로 할당이 된 것이다.

하지만 여기서 rAF callback에서 값을 바꾸더라도 애니메이션이 의도한대로 보여지지 않는다. 아까 말했듯 rAF는 painting이전에 실행되기 때문이다.

⚠️ Safari는 rAF가 painting 이후에 호출됐던 적이 있다.

Microtask (Promises)

탄생 배경? 아래의 코드는 몇개의 Event를 만들어낼까?

for (let i = 0; i < 100; i++) {
  const span = document.createElement("span")
  document.body.appendChild(span)
  span.textContent = "Hello"
}

Loop전체에 해당하는 1개일까? 답은 200개이다. textNode가 span에 삽입되는 과정이 100개 추가되어서 그렇다.

DOM 조작은 이처럼 성능에 문제가 되었고, 개발자들은 이 과정을 batch로 처리하고 싶었다.

그래서 MutationObserver가 나오고 Microtask가 나왔다.

Microtask는 일반적으로 JavaScript가 모두 실행되고 JavaScript Stack이 비었을 때 실행된다.

Promise.resolve().then(() => console.log("World"))
console.log("Hello")

아래 로그를 다 실행하면 위의 callback이 실행된다.

요약

  • Tasks: 한번에 하나씩. 새로운 작업은 Queue처리
  • Animation callbacks: 현재 Queue에 있는 작업을 모두 처리. 새로운 작업은 다시 Queue에 쌓임
  • Microtasks: 새로 들어오는 작업을 포함하여 Queue를 모두 비워야함. 때문에 계속 작업이 들어오는 경우 render blokcing!

퀴즈

button.addEventListener("click", () => {
  Promise.resolve().then(() => console.log("Microtask 1"))
  console.log("Listener 1")
})
button.addEventListener("click", () => {
  Promise.resolve().then(() => console.log("Microtask 2"))
  cosnole.log("Listener 2")
})
  • 버튼을 직접 눌렀을 때: Listener 1 -> Microtask 1 -> Listener 2 -> Microtask 2
    • 클릭 event -> empty -> Microtask 이런식
  • button.click(): Listener 1 -> Listener 2 -> Microtask 1 -> Microtask 2
    • 버튼 클릭 task의 컨텐스트가 살아있어서 Microtask가 끼어들지 못한다.
const nextClick = new Promise(resolve => {
  link.addEventListener("click", resolve, { once: true })
})

nextClick.then(event => {
  event.preventDefault()
})

link.click()

예상 답: click이벤트에는 아무것도 없다. task queue를 계속 차지하고 있어서 microtask인 promise가 eventListener를 붙여주지 못함.

JavaScript - Event Loop